Skip to content

fix(audit): per-invocation attribution for egress-proxy + MCP consent events (#341, #366) - #367

Merged
initializ-mk merged 3 commits into
mainfrom
fix/audit-attribution-mcp-proxy-seq
Jul 26, 2026
Merged

fix(audit): per-invocation attribution for egress-proxy + MCP consent events (#341, #366)#367
initializ-mk merged 3 commits into
mainfrom
fix/audit-attribution-mcp-proxy-seq

Conversation

@initializ-mk

Copy link
Copy Markdown
Contributor

Fixes #341 and #366 together — both are "events emitted outside the request goroutine lose per-invocation attribution." A shared SequenceRegistry is the unifying seam.

Shared foundation — coreruntime.SequenceRegistry

A registry mapping each invocation's live *atomic.Int64 sequence counter to its (correlation_id, task_id). The runner registers the counter at every A2A request-entry point and evicts it at the invocation boundary. Out-of-band emitters look the counter up and advance the same counter the in-context path uses — one gap-free sequence. A miss returns 0 (seq-less, never a wrong/duplicate seq).

#341 — egress-proxy events now carry seq

Subprocess egress-proxy events (source=proxy) carried task_id/correlation_id (#338) but no seq, so they were invisible to gap-detection (which walks seq within a (correlation_id, task_id) group). Root cause: the proxy is a separate 127.0.0.1 forwarder with no request ctx.

Fix: the proxy OnAttempt recovers (corr, task) from the replayed Proxy-Authorization creds (#338) and calls SequenceRegistry.NextSequenceFor → proxy events join the gap-free chain. Trace cross-link still needs the ctx span, so it stays unavailable (documented).

#366 — MCP consent audit + egress attribution

A. mcp_auth_resolved was double-emitted, 2 of 3 sites unattributed. The waking gate (mcpAuthGate.Await) already emits an attributed mcp_auth_resolved (grant) / mcp_auth_timeout (refusal). The resume channels emitted a second, unattributed copy on a different request:

  • POST /mcp/consent: dropped entirely (Peek guarantees a waiter → the gate covers it; a resolved event for a refusal was also misleading).
  • loopback callback: now emits only when Resolve finds no waiter (a late grant after timeout — genuinely unattributed, no invocation).

B. The loopback callback's code→token exchange ran on the browser request ctx → unattributed egress. The parked invocation is still in flight (its counter is still registered), so: add CorrelationID to authgate.Spec (travels with the first waiter), recover (corr, task) via engine.Peek at the callback, and seed the completion ctx (WithCorrelationID/WithTaskID + the registered counter). The token exchange now attributes + seq's to the parked invocation.

Scope boundary (documented, not fixed here)

The pooled MCP connection establishment (subject_pool.go, context.Background()) surfaces some resume-time mcp.atlassian.com egress unattributed. A connection is shared across invocations, so that egress is infrastructure (like startup) rather than cleanly per-invocation — noted as a follow-up rather than forced onto one arbitrary invocation.

Tests

  • Registry: Register/Get/Evict/NextSequenceFor, shared-counter, -race, nil-safe.
  • Runner registerInvocationSeq shares the in-context counter + evicts.
  • Callback seeds the parked (corr, task) + shared counter; authgate + consent + mcp + runtime suites green.

gofmt + go vet + golangci-lint clean across forge-core + forge-cli; doc-links resolve.

Egress-proxy audit events (source=proxy) carried task_id/correlation_id (#338)
but no seq, so they were invisible to gap-detection (which walks seq within a
(correlation_id, task_id) group). Root cause: the proxy is a separate 127.0.0.1
forwarder with no request ctx, so it emits via plain Emit and skips the
ctx-scoped counter.

Add coreruntime.SequenceRegistry — a per-invocation counter registry keyed by
(correlation_id, task_id). The runner registers each invocation's counter at
request entry (all three A2A entry points) and evicts at the invocation
boundary; the proxy OnAttempt recovers (corr, task) from the replayed
Proxy-Authorization creds and advances the SAME counter via NextSequenceFor, so
proxy events join the gap-free seq chain. A miss returns 0 (seq-less, never a
wrong/duplicate seq) — startup + unattributed events are unaffected.

This registry is also the seam #366 uses to attribute MCP consent-resume egress.

Tests: registry Register/Get/Evict/NextSequenceFor + shared-counter + race +
nil-safe; runner registerInvocationSeq shares the in-context counter and evicts.
Build/vet/lint clean.
…366)

Two attribution defects on the MCP delegated-consent path:

A. mcp_auth_resolved was double-emitted, and 2 of 3 sites dropped attribution.
   The waking gate (mcpAuthGate.Await) already emits an attributed
   mcp_auth_resolved (grant) / mcp_auth_timeout (refusal). The resume channels
   emitted a second, UNATTRIBUTED copy via plain Emit on a different request:
   - POST /mcp/consent: dropped entirely — Peek guarantees a waiter, so the gate
     covers it; a resolved event for a refusal (decision=timeout) was misleading.
   - loopback callback: now emits ONLY when Resolve finds no waiter (a late grant
     after the original call timed out — genuinely unattributed, no invocation).

B. The loopback callback's code->token exchange ran on the browser request ctx,
   so its egress surfaced unattributed. The parked invocation is still in flight
   (its seq counter is still registered #341), so: add CorrelationID to
   authgate.Spec (travels with the first waiter), recover (correlation_id,
   task_id) via engine.Peek at the callback, and seed the completion ctx
   (WithCorrelationID/WithTaskID + the registered seq counter). The token
   exchange (ExchangeCodeCtx) now attributes + seq's to the parked invocation.

Not covered here: the pooled MCP connection establishment
(subject_pool.go context.Background()) — a connection is shared across
invocations, so that egress is infrastructure (like startup), not cleanly
per-invocation; documented as a follow-up boundary.

Tests: callback seeds the parked (corr,task)+shared counter; authgate +
consent suites green.
…le + attributed (#366)

audit-logging.md: update the plain-Emit exception for source=proxy — proxy
events now carry a correct seq via the SequenceRegistry (only trace cross-link
remains unavailable). delegated-consent.md: mcp_auth_resolved/timeout emitted
once by the resuming gate with the invocation's correlation/task/seq; the
late-grant no-waiter case is the only unattributed exception.

@initializ-mk initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: per-invocation attribution for egress-proxy + MCP consent (#341, #366)

Clean, well-reasoned PR. Concurrency is correct, leak-prevention is sound, and the MCP attribution stays strictly on the observability side of the security boundary — no cross-user misattribution. No blocking or security findings. Merge-ready. CI fully green.

SequenceRegistry — race- and leak-safe ✅

  • Concurrency: map is mutex-guarded; NextSequenceFor does Add(1) on the live *atomic counter, so out-of-band and in-context emitters advance the same counter — no lost increments or duplicate seqs. Eviction racing an in-flight Add is safe (the counter object outlives the map entry via the returned pointer; no panic, no race — the -race suite backs this).
  • Leak-safe: defer r.registerInvocationSeq(ctx)() registers immediately, evicts on return including panic unwinding. Nil/empty-key guards prevent phantom entries.
  • Correct sentinel: real seqs start at 1 (Add(1) on zero), miss returns 0 → JSON-omitted, so "unregistered" and a real seq never collide.
  • Key safety: corr + "\x00" + task rules out the ("a","bc") vs ("ab","c") ambiguity.

#341 wiring — the key match holds ✅

Verified the thing that would silently defeat this: at all three A2A entry points (JSON-RPC, executeTask, REST), EnsureCorrelationID + WithTaskID + EnsureSequenceCounter run before registerInvocationSeq, so the registration key is the real (corr, task) the subprocess later replays via Proxy-Authorization (#338). The proxy's NextSequenceFor hits the right counter; proxy events join the gap-free chain.

#366 MCP consent — attribution binding is secure ✅

  • No cross-user misattribution: the callback recovers (corr, task) from engine.Peek(b.subject, b.server) where b.subject is the state-bound subject (PKCE/state/session validation unchanged). The gate is per-(subject, server), so the exchange attributes to the invocation that parked on that subject's gate. Attributing to the first same-subject waiter (when several exist) is deterministic and same-subject.
  • Observability-only: the seeded ctx changes only correlation/task/seq — it does not touch what token is minted, for whom, or the resume decision, so it can't cause a wrong token to be exchanged/stored. Good separation across the security boundary.
  • Double-emit removal loses no event: POST /mcp/consent drops its emit because Peek guarantees a waiter and the waking gate emits the attributed terminal event (and a resolved for a timeout refusal was genuinely misleading). The loopback callback now emits only on Resolve-finds-no-waiter — the genuine "late grant after timeout" case, marked late: true and honestly unattributed (no live invocation).
  • The scope boundary (pooled subject_pool.go connection = shared-across-invocations infra, deferred rather than forced onto one arbitrary invocation) is the right judgment and honestly documented.

Two things I considered and dismissed (recording so they don't resurface)

  1. Nested register/evict: not a concern — tasks/send (JSON-RPC + REST) delegates to executeTask, which registers internally, while the registerInvocationSeq defers in the streaming (tasks/sendSubscribe) handlers cover the inline-executing streaming path. A request is either non-streaming or streaming, so the two paths are mutually exclusive — no double-registration, no early-evict window.
  2. Single registry mutex: taken per proxy egress event + per request entry/exit, but O(1) map ops under a brief lock — fine at realistic audit volume; sharding/sync.Map would be premature.

Tests cover the registry (Register/Get/Evict/NextSequenceFor, shared-counter, -race, nil-safe), the runner sharing+evicting, and the callback seeding the parked (corr, task) + counter.

Nice design — the SequenceRegistry seam unifying both defects is clean, and keeping attribution observability-only leaves the delegated-consent security model untouched.

@initializ-mk
initializ-mk merged commit fd9739d into main Jul 26, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Egress: proxy audit events missing per-invocation seq (gap-detection blind spot)

1 participant